feat: Add Mattermost channel support#428
feat: Add Mattermost channel support#428unverbraucht wants to merge 20 commits intospacedriveapp:mainfrom
Conversation
Implements a full Mattermost adapter using a custom HTTP + WebSocket
client (reqwest + tokio-tungstenite), following the existing adapter
architecture.
Features:
- WebSocket-based inbound messages with exponential backoff reconnection
- Text responses, streaming edits (ZWS placeholder + throttled PUT),
file uploads, reactions, typing indicators, thread replies
- History fetch via /channels/{id}/posts
- Named adapter instances with per-instance permissions
- Hot-reload of permissions via spawn_file_watcher
- Conversation IDs: mattermost:{team_id}:{channel_id} and
mattermost:{team_id}:dm:{user_id}
- Fail-closed permission filtering: messages without a team_id are
rejected when team/channel filters are configured
- URL validation at config load time
Config:
[messaging.mattermost]
enabled = true
base_url = "https://mattermost.example.com"
token = "env:MATTERMOST_TOKEN"
team_id = "optional-default-team-id"
Resolved conflicts in src/config.rs (refactored to submodules), src/main.rs, src/messaging.rs, and src/messaging/target.rs. Distributed Mattermost config additions across the new config submodule structure introduced upstream.
- Add Mattermost platform to ChannelSettingCard, ChannelEditModal, Settings, and platformIcons - Server URL and access token credential inputs - Backend: read mattermost config into instances list in messaging_status - Backend: write base_url and token to TOML via create_messaging_instance - Mattermost instances configured manually in config.toml appear in the UI
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughAdds first-class Mattermost support: docs, frontend inputs and API fields, TOML schema and loader, new config types and permissions, binding.team_id, secrets registration, hot-reload watcher/main wiring, broadcast target normalization, and a full Rust Mattermost messaging adapter with tests. Changes
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related issues
Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
📝 Coding Plan
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Empty dm_allowed_users blocks all DMs (fail-closed). Non-empty list allows only the specified Mattermost user IDs, consistent with Slack and Telegram adapter behaviour. All other layers (config, TOML schema, permissions merge from bindings, bindings API, UI TagInput) were already in place.
There was a problem hiding this comment.
Actionable comments posted: 10
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/config/types.rs (1)
1543-1612:⚠️ Potential issue | 🔴 CriticalChannel-scoped Mattermost bindings never match.
At Line 1543 the generic
channel_idsgate only checks Discord/Slack/Twitch metadata. For a Mattermost binding withchannel_ids, it returnsfalsebefore the Mattermost-specific branch at Line 1604 executes, so channel-scoped Mattermost bindings are effectively dead.🎯 Proposed fix
- if !self.channel_ids.is_empty() { + if !self.channel_ids.is_empty() && self.channel != "mattermost" { let message_channel = message .metadata .get("discord_channel_id") .and_then(|v| v.as_u64()) .map(|v| v.to_string());🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/config/types.rs` around lines 1543 - 1612, The generic channel_ids gate (using message.metadata discord/slack/twitch) prevents Mattermost-specific matching; change the logic so Mattermost bindings are handled by the Mattermost-specific branch instead of being short-circuited. Concretely, update the initial if !self.channel_ids.is_empty() block to skip the generic Discord/Slack/Twitch checks when self.channel == "mattermost" (e.g. make the condition if !self.channel_ids.is_empty() && self.channel != "mattermost") or alternatively include message.metadata.get("mattermost_channel_id").and_then(|v| v.as_str()) in the direct_match calculation; reference self.channel_ids, self.channel, message.metadata and the later mattermost_channel_id check so the Mattermost-specific branch (the block that reads "mattermost_channel_id" and the one guarded by if self.channel == "mattermost") can correctly match.
🧹 Nitpick comments (1)
interface/src/components/ChannelSettingCard.tsx (1)
642-652: Add lightweight client-side URL validation for Mattermost server URL.Line 643 currently only checks non-empty input. Validating URL format here gives faster, clearer feedback before submit.
Suggested patch
} else if (platform === "mattermost") { - if (!credentialInputs.mattermost_base_url?.trim()) { + const baseUrl = credentialInputs.mattermost_base_url?.trim(); + if (!baseUrl) { setMessage({text: "Server URL is required", type: "error"}); return; } + try { + new URL(baseUrl); + } catch { + setMessage({text: "Server URL must be a valid absolute URL", type: "error"}); + return; + } if (!credentialInputs.mattermost_token?.trim()) { setMessage({text: "Access token is required", type: "error"}); return; } - credentials.mattermost_base_url = credentialInputs.mattermost_base_url.trim(); + credentials.mattermost_base_url = baseUrl; credentials.mattermost_token = credentialInputs.mattermost_token.trim(); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@interface/src/components/ChannelSettingCard.tsx` around lines 642 - 652, In the platform === "mattermost" branch of ChannelSettingCard, replace the simple non-empty check for credentialInputs.mattermost_base_url with a lightweight client-side URL validation: trim the input, attempt to validate that it is a well-formed HTTP/HTTPS URL (for example with new URL(trimmed) and checking protocol is http: or https:), and if invalid call setMessage({text: "Invalid server URL", type: "error"}) and return; only after it passes validation assign credentials.mattermost_base_url = trimmed and proceed; keep the existing token trim/validation logic intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/api/messaging.rs`:
- Around line 1331-1337: The Mattermost branch writes
credentials.mattermost_base_url into platform_table before validating it, which
can corrupt config if Config::load_from_path() later fails; modify the logic in
the "mattermost" arm to first validate the provided URL (e.g., parse with
url::Url::parse or similar) and only set platform_table["base_url"] when
validation succeeds, and if you still call Config::load_from_path() ensure you
check its Result and rollback/remove the recently-inserted keys from
platform_table (or avoid mutating platform_table until validation passes) so a
bad URL never gets persisted; apply the same pattern to the other occurrences
around the other branches referenced (the sections around the previous
Config::load_from_path() calls).
- Around line 1520-1522: The match arm accepting "mattermost" currently does not
clear the default instance credentials, leaving base_url and token set after a
delete; update the default-instance cleanup code path (the match on platform and
the cleanup block around lines 1578-1619) so that when platform == "mattermost"
you explicitly clear the stored credentials (set base_url and token to
None/empty on the config/struct representing the default Mattermost adapter),
persist the updated configuration/state (or delete those fields in the DB), and
only return success after the changes are saved so the default adapter cannot
reappear on reload.
In `@src/config/load.rs`:
- Around line 2232-2255: The current use of filter_map on mm.instances drops
Mattermost instances with unresolved token/base_url before
validate_named_messaging_adapters() runs; instead iterate over mm.instances
(e.g., with map or map + collect) and always produce a MattermostInstanceConfig
record for each instance, but if token.is_none() || base_url.is_none() set
enabled: false (and keep the original instance.name, team_id, dm_allowed_users,
max_attachment_bytes), emit the existing tracing::warn with context, and
preserve the unresolved token/base_url values (or use empty strings if the
struct requires) so validate_named_messaging_adapters() and the settings UI
still see the named adapter present.
In `@src/config/watcher.rs`:
- Around line 245-251: The watcher currently recreates an ArcSwap for each named
Mattermost adapter and immediately drops it so updates never reach running
adapters; change this to keep a shared ArcSwap handle per adapter runtime key
and update that stored value on reload. Concretely: instead of creating a new
local ArcSwap in the adapter initialization, register its ArcSwap handle in a
central registry/map keyed by the adapter runtime key (the same key used to
identify "mattermost:{instance}"), and in the watcher code (where
mattermost_permissions and MattermostPermissions::from_config are used) iterate
the registry entries for mattermost adapters and call
.store(Arc::new(new_perms)) on each ArcSwap handle; ensure the adapter
construction uses the shared ArcSwap handle from the registry rather than
building and dropping its own.
In `@src/messaging/mattermost.rs`:
- Around line 532-540: StreamEnd currently calls edit_post with the full
active.accumulated_text which can exceed MAX_MESSAGE_LENGTH and cause the final
edit to fail; update the OutboundResponse::StreamEnd handling to finalize
streaming by chunking accumulated_text into slices no larger than
MAX_MESSAGE_LENGTH and apply them safely (e.g., edit_post the first chunk for
the placeholder/post_id via active.post_id and then create_post or append
additional posts for remaining chunks), using the existing MAX_MESSAGE_LENGTH
constant and the edit_post/create_post methods to ensure no single edit exceeds
the limit and to remove the placeholder correctly; reference
OutboundResponse::StreamEnd, active.accumulated_text, active.post_id, edit_post,
create_post and MAX_MESSAGE_LENGTH when implementing the split-and-send logic.
- Around line 821-894: When constructing inbound DMs, enforce
MattermostPermissions.dm_allowed_users and add the directed-message hints to
metadata: if post.channel_type indicates a DM ("D" or "G") check
permissions.dm_allowed_users (or per-team list) and return None if the sender
isn't allowed; also add metadata flags the binding layer expects (e.g., a
direct_message/dm_hint boolean and mention-reply-related fields derived from
permissions.require_mention and post root/mentions) into the same metadata
HashMap before creating the InboundMessage so require_mention and
dm_allowed_users routing work (update logic around post.channel_type,
permissions, metadata, and the InboundMessage construction).
- Around line 490-497: OutboundResponse::StreamStart only uses metadata
"mattermost_root_id" so streamed replies escape threads; change the root_id
resolution to mirror the text path: first try
message.metadata.get("mattermost_root_id").and_then(|v| v.as_str()), then fall
back to the REPLY_TO_MESSAGE_ID metadata (or constant) if the first is None, and
pass that resolved root_id into create_post(channel_id, "\u{200B}", root_id).
Update the root_id variable in the StreamStart branch (around
OutboundResponse::StreamStart and the create_post call) to use this fallback
logic so streamed placeholders remain in the original thread.
- Around line 150-181: start_typing() currently spawns a new background task and
inserts its JoinHandle into typing_tasks without stopping any previous task,
causing leaks; at the top of start_typing() call
self.stop_typing(channel_id).await to abort any existing task for that channel
before spawning a new one, and continue using the same
channel_id/channel_id_owned logic and TYPING_INDICATOR_INTERVAL. In shutdown(),
replace the write().await.clear() on typing_tasks with iterating over the map's
values and calling abort() on each JoinHandle (and optionally await or check
join results) to ensure all spawned tasks are terminated. Finally, replace the
discarded shutdown channel send (the current let _ = ...) with proper error
handling (log or return the error) so failures to notify shutdown are not
silently ignored.
In `@src/messaging/target.rs`:
- Around line 143-161: The Mattermost branch currently drops the
instance/runtime adapter key when parsing channel.id (the match arm under
"mattermost" that builds parts from channel.id), causing named instances like
mattermost:{instance}:... to lose their adapter; update that logic so when
platform_meta lacks mattermost_channel_id you parse parts and, for patterns
["mattermost", instance, "dm", user_id] return a conversation id that preserves
the instance (e.g. format!("{instance}:dm:{user_id}")), and for ["mattermost",
instance, channel_id] return format!("{instance}:{channel_id}") instead of only
the channel_id; keep using json_value_to_string and channel.platform_meta as
before and only fall back to None for non-matching shapes.
- Around line 261-269: normalize_mattermost_target currently returns the full
suffix after strip_repeated_prefix instead of the actual send target; update it
to parse the suffix by splitting on ':' and return the real channel or DM id: if
the stripped target is empty return None, if it contains ':' split into parts
and if the first part is "dm" return "dm:{user_id}" using the last part,
otherwise return the last part (the channel id), and if there is no ':' return
the target as-is; use the existing normalize_mattermost_target and
strip_repeated_prefix symbols to locate and modify the logic accordingly.
---
Outside diff comments:
In `@src/config/types.rs`:
- Around line 1543-1612: The generic channel_ids gate (using message.metadata
discord/slack/twitch) prevents Mattermost-specific matching; change the logic so
Mattermost bindings are handled by the Mattermost-specific branch instead of
being short-circuited. Concretely, update the initial if
!self.channel_ids.is_empty() block to skip the generic Discord/Slack/Twitch
checks when self.channel == "mattermost" (e.g. make the condition if
!self.channel_ids.is_empty() && self.channel != "mattermost") or alternatively
include message.metadata.get("mattermost_channel_id").and_then(|v| v.as_str())
in the direct_match calculation; reference self.channel_ids, self.channel,
message.metadata and the later mattermost_channel_id check so the
Mattermost-specific branch (the block that reads "mattermost_channel_id" and the
one guarded by if self.channel == "mattermost") can correctly match.
---
Nitpick comments:
In `@interface/src/components/ChannelSettingCard.tsx`:
- Around line 642-652: In the platform === "mattermost" branch of
ChannelSettingCard, replace the simple non-empty check for
credentialInputs.mattermost_base_url with a lightweight client-side URL
validation: trim the input, attempt to validate that it is a well-formed
HTTP/HTTPS URL (for example with new URL(trimmed) and checking protocol is http:
or https:), and if invalid call setMessage({text: "Invalid server URL", type:
"error"}) and return; only after it passes validation assign
credentials.mattermost_base_url = trimmed and proceed; keep the existing token
trim/validation logic intact.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: f94c442a-2f2b-48e9-82b0-bb38672e10ae
⛔ Files ignored due to path filters (2)
Cargo.lockis excluded by!**/*.lock,!**/*.lockCargo.tomlis excluded by!**/*.toml
📒 Files selected for processing (17)
docs/mattermost.mdinterface/src/api/client.tsinterface/src/components/ChannelEditModal.tsxinterface/src/components/ChannelSettingCard.tsxinterface/src/lib/platformIcons.tsxinterface/src/routes/Settings.tsxsrc/api/messaging.rssrc/config.rssrc/config/load.rssrc/config/permissions.rssrc/config/toml_schema.rssrc/config/types.rssrc/config/watcher.rssrc/main.rssrc/messaging.rssrc/messaging/mattermost.rssrc/messaging/target.rs
SEC2: add sanitize_reaction_name() — resolves unicode emoji to shortcodes
via the emojis crate, strips colon wrappers, lowercases plain names.
FN1: user identity resolution — add user_identity_cache, resolve
display name from /users/{id} API on first encounter, set
sender_display_name metadata and formatted_author.
FN2: channel name resolution — add channel_name_cache, resolve
display_name from /channels/{id} API on first encounter, set
mattermost_channel_name and CHANNEL_NAME metadata.
FN3: fetch_history now returns timestamps parsed from post.create_at
milliseconds instead of always None.
FN4: bot mention detection — check message text for @bot_username,
set mattermost_mentions_or_replies_to_bot metadata flag so
require_mention routing mode works correctly.
Also adds 12 new unit tests covering these fixes.
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
src/config/types.rs (2)
1651-1659:⚠️ Potential issue | 🟠 Major
require_mentiondoes not recognize Mattermost metadata.Mattermost inbound metadata uses
mattermost_mentions_or_replies_to_bot, but the mention-key switch has nomattermostarm, so bindings withrequire_mention = truewill always drop Mattermost messages.💡 Proposed fix
let mention_key = match message.source.as_str() { "discord" => "discord_mentions_or_replies_to_bot", "slack" => "slack_mentions_or_replies_to_bot", "twitch" => "twitch_mentions_or_replies_to_bot", "telegram" => "telegram_mentions_or_replies_to_bot", + "mattermost" => "mattermost_mentions_or_replies_to_bot", // Unknown platforms: if require_mention is set, default to // requiring a mention (safe default). _ => return false, };🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/config/types.rs` around lines 1651 - 1659, The match on message.source (which sets mention_key) is missing a branch for Mattermost, causing require_mention checks to drop Mattermost messages; update the match in the code that defines mention_key (the match on message.source.as_str()) to include a "mattermost" arm that maps to "mattermost_mentions_or_replies_to_bot" so Mattermost inbound metadata is recognized the same as other platforms.
1543-1576:⚠️ Potential issue | 🔴 CriticalMattermost
channel_idsbindings are currently blocked by the generic channel filter.Line 1543 applies the generic Discord/Slack/Twitch channel check for all platforms; for Mattermost this returns false before the Mattermost branch at Line 1604 runs.
💡 Proposed fix
- if !self.channel_ids.is_empty() { + if !self.channel_ids.is_empty() && self.channel != "mattermost" { let message_channel = message .metadata .get("discord_channel_id") .and_then(|v| v.as_u64()) .map(|v| v.to_string()); @@ - if !self.channel_ids.is_empty() && self.channel == "mattermost" { + if !self.channel_ids.is_empty() && self.channel == "mattermost" { let message_channel = message .metadata .get("mattermost_channel_id") .and_then(|v| v.as_str()); if !self.channel_ids.iter().any(|id| Some(id.as_str()) == message_channel) { return false; } }Also applies to: 1604-1612
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/config/types.rs` around lines 1543 - 1576, The generic channel filtering block using self.channel_ids and the computed variables message_channel, slack_channel, and twitch_channel is preventing Mattermost checks from running; update the channel extraction and matching logic to also handle Mattermost by reading message.metadata.get("mattermost_channel_id").and_then(|v| v.as_str()) (and include a parent mattermost id if applicable) and include that value in the direct_match boolean (similar to slack_channel/twitch_channel checks), and ensure the same adjustment is applied to the later Mattermost-specific branch referenced around the parent/channel checks (the code that computes message_channel, parent_channel, direct_match, and parent_match).
♻️ Duplicate comments (8)
src/messaging/mattermost.rs (8)
518-523:⚠️ Potential issue | 🟠 Major
StreamStartmisses thread fallback metadata.This path only uses
mattermost_root_id; it should also fallback toREPLY_TO_MESSAGE_IDlike the text path does.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/messaging/mattermost.rs` around lines 518 - 523, OutboundResponse::StreamStart currently extracts only message.metadata["mattermost_root_id"] and calls start_typing without the same thread fallback used elsewhere; update the StreamStart branch to follow the text-path logic by checking message.metadata for "mattermost_root_id" and, if absent, falling back to REPLY_TO_MESSAGE_ID before using that value for threaded behavior (the same metadata lookup pattern used in the text response branch), ensuring start_typing/channel handling uses the resolved thread id from message.metadata.
154-186:⚠️ Potential issue | 🟠 MajorTyping tasks are not safely replaced/terminated.
Line 185 overwrites existing channel task handles without explicit abort, and Line 770 clears handles on shutdown without aborting them.
#!/bin/bash # Verify typing task lifecycle handling in start_typing/shutdown rg -n -C4 'async fn start_typing|typing_tasks.*insert|async fn shutdown|typing_tasks.*clear' src/messaging/mattermost.rs💡 Proposed fix
async fn start_typing(&self, channel_id: &str) { + self.stop_typing(channel_id).await; let Some(user_id) = self.bot_user_id.get().cloned() else { return; }; @@ - self.typing_tasks.write().await.clear(); + let mut typing_tasks = self.typing_tasks.write().await; + for (_, handle) in typing_tasks.drain() { + handle.abort(); + }Also applies to: 770-771
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/messaging/mattermost.rs` around lines 154 - 186, The start_typing function currently inserts a new tokio::task::JoinHandle into typing_tasks without stopping any existing task for the same channel; update start_typing to check typing_tasks (by channel_id) for an existing handle, call abort() on it and optionally await its completion or ignore the result before inserting the new handle so you don't leak duplicate tasks; likewise modify the shutdown logic (the code that currently clears typing_tasks) to instead iterate all stored JoinHandles, call abort() on each handle (and await or detach as appropriate) before removing them so all background typing tasks are explicitly terminated — refer to the start_typing method, typing_tasks field and the shutdown function to locate the changes.
659-669:⚠️ Potential issue | 🟠 MajorPost creation after file upload lacks HTTP status handling.
Both paths send
POST /postsand ignore non-success responses, which can silently drop file messages.#!/bin/bash # Verify /posts calls after file upload without explicit status checks rg -n -C6 'failed to create post with file|post\(self\.api_url\("/posts"\)\)' src/messaging/mattermost.rsAlso applies to: 818-829
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/messaging/mattermost.rs` around lines 659 - 669, The POST to /posts after file upload (the request built with self.client.post(self.api_url("/posts")).bearer_auth(self.token.as_ref()).json(...).send().await.context("failed to create post with file")?) does not check HTTP status and can silently ignore non-2xx responses; update the call to inspect the Response returned by send() (e.g., assign to a variable), call response.error_for_status() or check status().is_success(), and return a contextual error (including status and body text) if not successful. Apply the same fix for the other /posts call that uses the "failed to create post" context (the second occurrence around the other upload path) so both paths surface non-success HTTP responses.
762-764:⚠️ Potential issue | 🟡 MinorDon’t silently discard shutdown send errors.
Line 763 uses
let _ =on aResult; handle/log it (or use.ok()if intentionally drop-safe for channel send).As per coding guidelines: "Don't silently discard errors. No
let _ =on Results. Handle them, log them, or propagate them. The only exception is.ok()on channel sends where the receiver may be dropped."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/messaging/mattermost.rs` around lines 762 - 764, The code currently discards the Result from sending the shutdown signal with "let _ = tx.send(()).await" on the shutdown_tx channel; change this to explicitly handle the Result from tx.send(()).await (e.g., use if let Err(err) = tx.send(()).await { log the error with tracing::warn! or tracing::error! } or, if it's intentionally safe to ignore a dropped receiver, call .ok() on the Result) so you no longer silently discard errors; locate the send call on the shutdown_tx (the shutdown_tx.write().await.take() -> tx.send(()).await expression) and replace the "let _ =" pattern with explicit logging or .ok() as appropriate.
583-587:⚠️ Potential issue | 🟡 MinorFail fast when
bot_user_idis uninitialized for reactions.Defaulting to an empty user ID produces opaque server-side reaction errors.
💡 Proposed fix
- let bot_user_id = self - .bot_user_id - .get() - .map(|s| s.as_ref().to_string()) - .unwrap_or_default(); + let bot_user_id = self + .bot_user_id + .get() + .ok_or_else(|| anyhow::anyhow!("bot_user_id not initialized"))? + .as_ref() + .to_string();🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/messaging/mattermost.rs` around lines 583 - 587, The code currently defaults bot_user_id to an empty string which causes opaque server errors when sending reactions; replace the unwrap_or_default usage on self.bot_user_id.get().map(|s| s.as_ref().to_string()).unwrap_or_default() with an explicit failure path: either call expect/panic with a clear message like "bot_user_id not initialized for reactions" or, preferably, change the surrounding handler to return a Result and use ok_or_else / map_err to return a descriptive error (e.g., "missing bot_user_id") so callers can handle it instead of sending an empty ID; update any call sites to handle the Result if you choose propagation.
308-317:⚠️ Potential issue | 🟠 MajorCheck
/users/meHTTP status before JSON parsing.On auth/config failures, this currently reports a parse error instead of surfacing status/body details.
#!/bin/bash # Verify /users/me path currently parses JSON directly rg -n -C8 'get\(self\.api_url\("/users/me"\)\)|failed to parse user response' src/messaging/mattermost.rs🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/messaging/mattermost.rs` around lines 308 - 317, The current call to self.client.get(self.api_url("/users/me")).bearer_auth(self.token.as_ref()).send().await... then .json().await can mask non-2xx responses as JSON parse errors; modify the flow in the /users/me request (the block that assigns to let me: MattermostUser) to inspect the HTTP response status after send() and before json(): await the Response from send(), check response.status().is_success() (or use response.error_for_status()/error_for_status_ref()), and if not successful read the response text (response.text().await) and return an error/context that includes the status code and body; only call response.json().await when the status is successful so parse errors truly reflect JSON problems. Ensure you reference the same calls (self.client.get, api_url("/users/me"), bearer_auth, send, json) when making the change.
560-567:⚠️ Potential issue | 🟠 MajorFinal stream edit can exceed Mattermost max post length.
Line 564 edits with full
accumulated_text; large streams can fail finalization and leave placeholder/truncated content.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/messaging/mattermost.rs` around lines 560 - 567, The final stream edit in the OutboundResponse::StreamEnd branch uses active.accumulated_text directly and can exceed Mattermost's max post length; before calling self.edit_post(&active.post_id, &active.accumulated_text).await truncate or otherwise constrain active.accumulated_text to the Mattermost max (use a MAX_POST_LENGTH constant or a helper like truncate_to_max_post_length) and log a warning if truncation occurs, so edit_post receives a length-safe string and finalization won't fail.
1142-1149:⚠️ Potential issue | 🔴 CriticalUnsafe UTF-8 slicing can panic on non-ASCII input.
Line 1142 slices with
[..max_len]before finding a char boundary.#!/bin/bash # Verify split_message uses direct byte slicing before boundary correction rg -n -C4 'let search_region = &remaining\[\.\.max_len\];|floor_char_boundary' src/messaging/mattermost.rs💡 Proposed fix
- let search_region = &remaining[..max_len]; + let search_end = remaining.floor_char_boundary(max_len); + let search_region = &remaining[..search_end]; let break_point = search_region .rfind('\n') .or_else(|| search_region.rfind(' ')) - .unwrap_or(max_len); + .unwrap_or(search_end);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/messaging/mattermost.rs` around lines 1142 - 1149, The code currently does unsafe byte slicing with let search_region = &remaining[..max_len]; which can panic on UTF-8 boundaries; change it to compute a safe byte index for the desired maximum by using remaining.char_indices().nth(max_len).map(|(i,_)|i).unwrap_or(remaining.len()) (or similar) and then set search_region = &remaining[..safe_index]; keep using rfind to find break_point and pass that byte index into floor_char_boundary before slicing and pushing into chunks (update the logic around search_region, break_point, floor_char_boundary, and chunks.push to use the computed safe byte index rather than direct ..max_len slicing).
🧹 Nitpick comments (1)
src/messaging/mattermost.rs (1)
538-553: Avoid holdingactive_messageswrite lock across awaited network I/O.Line 552 awaits
edit_postwhile the write lock is held, which can stall other streams and status updates.💡 Proposed refactor
OutboundResponse::StreamChunk(chunk) => { - let mut active_messages = self.active_messages.write().await; - if let Some(active) = active_messages.get_mut(&message.id) { + let mut update: Option<(Arc<str>, String)> = None; + { + let mut active_messages = self.active_messages.write().await; + if let Some(active) = active_messages.get_mut(&message.id) { active.accumulated_text.push_str(&chunk); @@ - if let Err(error) = self.edit_post(&active.post_id, &display_text).await { - tracing::warn!(%error, "failed to edit streaming message"); - } + update = Some((active.post_id.clone(), display_text)); active.last_edit = Instant::now(); } } + if let Some((post_id, display_text)) = update + && let Err(error) = self.edit_post(&post_id, &display_text).await + { + tracing::warn!(%error, "failed to edit streaming message"); + } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/messaging/mattermost.rs` around lines 538 - 553, The code currently holds the active_messages write lock while awaiting self.edit_post, which can block other tasks; fix by extracting/cloning the needed data (e.g., clone active.post_id and active.accumulated_text or compute display_text) while holding the lock, then drop the lock before calling self.edit_post(&post_id, &display_text). After the await, reacquire the write lock to update active.last_edit (and any other state) only if the edit succeeded; use the same symbols active_messages, active.accumulated_text, active.post_id, edit_post, STREAM_EDIT_THROTTLE, and MAX_MESSAGE_LENGTH to locate and implement the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/messaging/mattermost.rs`:
- Around line 1067-1079: The identity lookup swallows network/JSON errors via
multiple .ok()? calls (e.g., the cache.read().await.get(user_id) path,
client.get(url).bearer_auth(token).send().await.ok()?, and
resp.json().await.ok()? when decoding MattermostUser and later in the similar
block at 1091-1103); replace those .ok()? chains with explicit error handling:
check resp.error_for_status() (or inspect resp.status()) and handle send() and
json() Result errors with matches, logging the error context (include user_id
and URL) or propagating a proper Result type instead of silently returning None,
and ensure the parsed MattermostUser (and its display_name()) is only used after
successful JSON deserialization.
---
Outside diff comments:
In `@src/config/types.rs`:
- Around line 1651-1659: The match on message.source (which sets mention_key) is
missing a branch for Mattermost, causing require_mention checks to drop
Mattermost messages; update the match in the code that defines mention_key (the
match on message.source.as_str()) to include a "mattermost" arm that maps to
"mattermost_mentions_or_replies_to_bot" so Mattermost inbound metadata is
recognized the same as other platforms.
- Around line 1543-1576: The generic channel filtering block using
self.channel_ids and the computed variables message_channel, slack_channel, and
twitch_channel is preventing Mattermost checks from running; update the channel
extraction and matching logic to also handle Mattermost by reading
message.metadata.get("mattermost_channel_id").and_then(|v| v.as_str()) (and
include a parent mattermost id if applicable) and include that value in the
direct_match boolean (similar to slack_channel/twitch_channel checks), and
ensure the same adjustment is applied to the later Mattermost-specific branch
referenced around the parent/channel checks (the code that computes
message_channel, parent_channel, direct_match, and parent_match).
---
Duplicate comments:
In `@src/messaging/mattermost.rs`:
- Around line 518-523: OutboundResponse::StreamStart currently extracts only
message.metadata["mattermost_root_id"] and calls start_typing without the same
thread fallback used elsewhere; update the StreamStart branch to follow the
text-path logic by checking message.metadata for "mattermost_root_id" and, if
absent, falling back to REPLY_TO_MESSAGE_ID before using that value for threaded
behavior (the same metadata lookup pattern used in the text response branch),
ensuring start_typing/channel handling uses the resolved thread id from
message.metadata.
- Around line 154-186: The start_typing function currently inserts a new
tokio::task::JoinHandle into typing_tasks without stopping any existing task for
the same channel; update start_typing to check typing_tasks (by channel_id) for
an existing handle, call abort() on it and optionally await its completion or
ignore the result before inserting the new handle so you don't leak duplicate
tasks; likewise modify the shutdown logic (the code that currently clears
typing_tasks) to instead iterate all stored JoinHandles, call abort() on each
handle (and await or detach as appropriate) before removing them so all
background typing tasks are explicitly terminated — refer to the start_typing
method, typing_tasks field and the shutdown function to locate the changes.
- Around line 659-669: The POST to /posts after file upload (the request built
with
self.client.post(self.api_url("/posts")).bearer_auth(self.token.as_ref()).json(...).send().await.context("failed
to create post with file")?) does not check HTTP status and can silently ignore
non-2xx responses; update the call to inspect the Response returned by send()
(e.g., assign to a variable), call response.error_for_status() or check
status().is_success(), and return a contextual error (including status and body
text) if not successful. Apply the same fix for the other /posts call that uses
the "failed to create post" context (the second occurrence around the other
upload path) so both paths surface non-success HTTP responses.
- Around line 762-764: The code currently discards the Result from sending the
shutdown signal with "let _ = tx.send(()).await" on the shutdown_tx channel;
change this to explicitly handle the Result from tx.send(()).await (e.g., use if
let Err(err) = tx.send(()).await { log the error with tracing::warn! or
tracing::error! } or, if it's intentionally safe to ignore a dropped receiver,
call .ok() on the Result) so you no longer silently discard errors; locate the
send call on the shutdown_tx (the shutdown_tx.write().await.take() ->
tx.send(()).await expression) and replace the "let _ =" pattern with explicit
logging or .ok() as appropriate.
- Around line 583-587: The code currently defaults bot_user_id to an empty
string which causes opaque server errors when sending reactions; replace the
unwrap_or_default usage on self.bot_user_id.get().map(|s|
s.as_ref().to_string()).unwrap_or_default() with an explicit failure path:
either call expect/panic with a clear message like "bot_user_id not initialized
for reactions" or, preferably, change the surrounding handler to return a Result
and use ok_or_else / map_err to return a descriptive error (e.g., "missing
bot_user_id") so callers can handle it instead of sending an empty ID; update
any call sites to handle the Result if you choose propagation.
- Around line 308-317: The current call to
self.client.get(self.api_url("/users/me")).bearer_auth(self.token.as_ref()).send().await...
then .json().await can mask non-2xx responses as JSON parse errors; modify the
flow in the /users/me request (the block that assigns to let me: MattermostUser)
to inspect the HTTP response status after send() and before json(): await the
Response from send(), check response.status().is_success() (or use
response.error_for_status()/error_for_status_ref()), and if not successful read
the response text (response.text().await) and return an error/context that
includes the status code and body; only call response.json().await when the
status is successful so parse errors truly reflect JSON problems. Ensure you
reference the same calls (self.client.get, api_url("/users/me"), bearer_auth,
send, json) when making the change.
- Around line 560-567: The final stream edit in the OutboundResponse::StreamEnd
branch uses active.accumulated_text directly and can exceed Mattermost's max
post length; before calling self.edit_post(&active.post_id,
&active.accumulated_text).await truncate or otherwise constrain
active.accumulated_text to the Mattermost max (use a MAX_POST_LENGTH constant or
a helper like truncate_to_max_post_length) and log a warning if truncation
occurs, so edit_post receives a length-safe string and finalization won't fail.
- Around line 1142-1149: The code currently does unsafe byte slicing with let
search_region = &remaining[..max_len]; which can panic on UTF-8 boundaries;
change it to compute a safe byte index for the desired maximum by using
remaining.char_indices().nth(max_len).map(|(i,_)|i).unwrap_or(remaining.len())
(or similar) and then set search_region = &remaining[..safe_index]; keep using
rfind to find break_point and pass that byte index into floor_char_boundary
before slicing and pushing into chunks (update the logic around search_region,
break_point, floor_char_boundary, and chunks.push to use the computed safe byte
index rather than direct ..max_len slicing).
---
Nitpick comments:
In `@src/messaging/mattermost.rs`:
- Around line 538-553: The code currently holds the active_messages write lock
while awaiting self.edit_post, which can block other tasks; fix by
extracting/cloning the needed data (e.g., clone active.post_id and
active.accumulated_text or compute display_text) while holding the lock, then
drop the lock before calling self.edit_post(&post_id, &display_text). After the
await, reacquire the write lock to update active.last_edit (and any other state)
only if the edit succeeded; use the same symbols active_messages,
active.accumulated_text, active.post_id, edit_post, STREAM_EDIT_THROTTLE, and
MAX_MESSAGE_LENGTH to locate and implement the change.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: ca599496-9b51-4c85-bc78-65074e5fafde
📒 Files selected for processing (2)
src/config/types.rssrc/messaging/mattermost.rs
mattermost.rs:
- Validate base_url has no path/query/fragment in new()
- Check /users/me HTTP status before .json() to surface auth errors
- start_typing() aborts previous task before spawning to prevent leaks
- shutdown() drains typing_tasks with abort() instead of clear()
- StreamStart now falls back to REPLY_TO_MESSAGE_ID for thread replies
- StreamEnd applies MAX_MESSAGE_LENGTH chunking to avoid oversized edits
- Reaction handler fails fast if bot_user_id not initialized
- respond() file attachment post checks HTTP status
- broadcast() file upload checks HTTP status
- broadcast() resolves dm:{user_id} targets via /channels/direct API
- split_message uses floor_char_boundary before slicing to prevent UTF-8 panics
- Identity/channel lookups log errors via tracing::debug instead of silent None
target.rs:
- resolve_broadcast_target extracts named adapter key from conversation ID
- normalize_mattermost_target strips team/instance prefixes down to bare channel_id or dm:{user_id}
api/messaging.rs:
- Validate mattermost base_url before persisting to config.toml
- Delete default Mattermost instance now clears all credential fields
config/load.rs:
- Named Mattermost instances with missing credentials are preserved but
disabled (map + enabled&&has_credentials) instead of silently dropped
There was a problem hiding this comment.
Actionable comments posted: 4
♻️ Duplicate comments (2)
src/messaging/mattermost.rs (2)
840-842:⚠️ Potential issue | 🟡 MinorDon’t silently discard shutdown signal errors.
let _ = tx.send(()).awaithides failure to notify the websocket task.As per coding guidelines: "Don't silently discard errors. No
let _ =on Results. Handle them, log them, or propagate them. The only exception is.ok()on channel sends where the receiver may be dropped"Suggested fix
if let Some(tx) = self.shutdown_tx.write().await.take() { - let _ = tx.send(()).await; + if let Err(error) = tx.send(()).await { + tracing::debug!(%error, adapter = %self.runtime_key, "shutdown receiver already closed"); + } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/messaging/mattermost.rs` around lines 840 - 842, The shutdown send currently discards errors with `let _ = tx.send(()).await;`; update the code around `self.shutdown_tx.write().await.take()` so the Result from `tx.send(()).await` is not silently ignored—handle the error by at minimum logging it (e.g., via the module logger or `error!`) or propagating it upward; ensure the failure to notify the websocket task is visible (include context like "failed to send shutdown signal to websocket task") and keep the existing `take()` behavior on `shutdown_tx`.
916-927:⚠️ Potential issue | 🟠 Major
broadcast()file uploads don’t validate/postsHTTP status.This path can silently report success even when Mattermost rejects the post (401/403/413/etc).
Suggested fix
- self.client + let post_response = self.client .post(self.api_url("/posts")) .bearer_auth(self.token.as_ref()) .json(&serde_json::json!({ "channel_id": target, "message": caption.unwrap_or_default(), "file_ids": file_ids, })) .send() .await .context("failed to create post with file")?; + let post_status = post_response.status(); + if !post_status.is_success() { + let body = post_response.text().await.unwrap_or_default(); + return Err(anyhow::anyhow!( + "mattermost POST /posts (broadcast file) failed with status {}: {body}", + post_status.as_u16() + ).into()); + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/messaging/mattermost.rs` around lines 916 - 927, The POST to /posts in broadcast() currently awaits send() but ignores the HTTP status; change it to capture the Response from self.client.post(self.api_url("/posts")).bearer_auth(...).json(...).send().await, then validate the status (e.g., use resp.error_for_status() or check resp.status().is_success()), and on non-success return a contextual error that includes the HTTP status and response body/text to surface Mattermost errors (401/403/413/etc.); update the error message passed to .context("failed to create post with file") to include these details.
🧹 Nitpick comments (2)
src/api/messaging.rs (2)
518-570: Renamemmto a descriptive variable name.Please avoid abbreviated local names here (e.g.,
mattermost_config) for readability and consistency with repo style.As per coding guidelines
**/*.rs: "Don't abbreviate variable names. Usequeuenotq,messagenotmsg,channelnotch. Common abbreviations likeconfigare fine".🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/api/messaging.rs` around lines 518 - 570, The local variable `mm` in the Mattermost block is an unclear abbreviation; rename it to a descriptive identifier like `mattermost_config` (or similar) wherever it's used in this block (the binding from doc.get("messaging").and_then(|m| m.get("mattermost")), the checks for base_url/token/enabled, the `named_instances` extraction, and the loop that computes `instance_name`, `instance_enabled`, and `instance_configured`) so the code remains readable and consistent; update all references passed into functions such as push_instance_status and calls to normalize_adapter_selector and ensure no other symbol names change.
1331-1347: Deduplicate Mattermost base URL validation.The same validation block appears in both default and named instance paths. Extracting a helper will reduce drift risk.
♻️ Proposed refactor
+fn is_valid_mattermost_origin(base_url: &str) -> bool { + url::Url::parse(base_url) + .map(|parsed_url| { + parsed_url.path() == "/" + && parsed_url.query().is_none() + && parsed_url.fragment().is_none() + }) + .unwrap_or(false) +} ... - if let Some(url) = &credentials.mattermost_base_url { - if url::Url::parse(url) - .map(|u| u.path() != "/" || u.query().is_some() || u.fragment().is_some()) - .unwrap_or(true) - { + if let Some(url) = &credentials.mattermost_base_url { + if !is_valid_mattermost_origin(url) { return Ok(Json(MessagingInstanceActionResponse { success: false, message: format!("invalid mattermost base_url: must be an origin URL (e.g. https://mm.example.com)"), })); } platform_table["base_url"] = toml_edit::value(url.as_str()); } ... - if let Some(url) = &credentials.mattermost_base_url { - if url::Url::parse(url) - .map(|u| u.path() != "/" || u.query().is_some() || u.fragment().is_some()) - .unwrap_or(true) - { + if let Some(url) = &credentials.mattermost_base_url { + if !is_valid_mattermost_origin(url) { return Ok(Json(MessagingInstanceActionResponse { success: false, message: format!("invalid mattermost base_url: must be an origin URL (e.g. https://mm.example.com)"), })); } instance_table["base_url"] = toml_edit::value(url.as_str()); }Also applies to: 1457-1473
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/api/messaging.rs` around lines 1331 - 1347, Duplicate Mattermost base_url parsing/validation occurs in the "mattermost" branch (checking credentials.mattermost_base_url with url::Url::parse and the path/query/fragment conditions) and should be extracted into a small helper to avoid drift; add a function like validate_mattermost_base_url(url: &str) -> Result<(), String> (or similar) that encapsulates the parse and the condition (path == "/" && query.is_none() && fragment.is_none()), call that helper from both places, and on Err return the same Json(MessagingInstanceActionResponse { success: false, message: ... }) behavior; update places that currently set platform_table["base_url"] to use the validated url after the helper returns Ok.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/messaging/mattermost.rs`:
- Around line 434-442: The code currently swallows JSON decode errors for
WebSocket posts (the chain that produces post_result from
MattermostWsEvent.data.get("post") and
serde_json::from_str::<MattermostPost>()); change this to explicitly handle and
log failures instead of using .ok(): extract the "post" string, attempt
serde_json::from_str in a match or if let Err(e) pattern, log the error with
context (including the raw text or post string and the error) and only proceed
when Ok(post) yields a MattermostPost; update references to post_result to use
the validated value so malformed payloads are visible in logs rather than
silently dropped.
- Around line 620-635: When handling OutboundResponse::StreamEnd, the overflow
chunks are being posted with root_id = None which breaks the thread; update the
create_post call inside the chunks loop to pass the original thread root (e.g.
Some(active.post_id) or Some(active.root_id) if the active message stores a
separate root_id) instead of None so overflow chunks remain in the same thread
as the edited post (adjust create_post invocation and any active struct field
used to hold the thread root).
In `@src/messaging/target.rs`:
- Around line 289-292: normalize_mattermost_target currently treats a
single-segment "dm" as a valid channel_id; update the normalization to reject
bare "dm" targets by ensuring the single-segment match (the branch handling
[channel_id]) also checks channel_id != "dm" (and non-empty) and returns None
for that case, and keep the existing two-segment ["dm", user_id] branch
unchanged so only "dm:<user_id>" forms are accepted; modify the match in
normalize_mattermost_target to add that explicit check.
- Around line 268-283: The function extract_mattermost_adapter_from_channel_id
is mis-matching default DM IDs (e.g. "mattermost:{team_id}:dm:{user_id}") as
named-instance IDs; update the match arms to inspect the third part (team_id)
instead of the fourth so only true named-instance IDs like ["mattermost",
instance, team_id, channel] (where team_id != "dm") and the 5-part
named-instance DM ["mattermost", instance, team_id, "dm", user_id] map to
"mattermost:{instance}", while default 3- or 4-part IDs with the third part ==
"dm" fall through to the default "mattermost" case; modify
extract_mattermost_adapter_from_channel_id accordingly so
resolve_broadcast_target no longer routes default DMs to a non-existent named
adapter.
---
Duplicate comments:
In `@src/messaging/mattermost.rs`:
- Around line 840-842: The shutdown send currently discards errors with `let _ =
tx.send(()).await;`; update the code around
`self.shutdown_tx.write().await.take()` so the Result from `tx.send(()).await`
is not silently ignored—handle the error by at minimum logging it (e.g., via the
module logger or `error!`) or propagating it upward; ensure the failure to
notify the websocket task is visible (include context like "failed to send
shutdown signal to websocket task") and keep the existing `take()` behavior on
`shutdown_tx`.
- Around line 916-927: The POST to /posts in broadcast() currently awaits send()
but ignores the HTTP status; change it to capture the Response from
self.client.post(self.api_url("/posts")).bearer_auth(...).json(...).send().await,
then validate the status (e.g., use resp.error_for_status() or check
resp.status().is_success()), and on non-success return a contextual error that
includes the HTTP status and response body/text to surface Mattermost errors
(401/403/413/etc.); update the error message passed to .context("failed to
create post with file") to include these details.
---
Nitpick comments:
In `@src/api/messaging.rs`:
- Around line 518-570: The local variable `mm` in the Mattermost block is an
unclear abbreviation; rename it to a descriptive identifier like
`mattermost_config` (or similar) wherever it's used in this block (the binding
from doc.get("messaging").and_then(|m| m.get("mattermost")), the checks for
base_url/token/enabled, the `named_instances` extraction, and the loop that
computes `instance_name`, `instance_enabled`, and `instance_configured`) so the
code remains readable and consistent; update all references passed into
functions such as push_instance_status and calls to normalize_adapter_selector
and ensure no other symbol names change.
- Around line 1331-1347: Duplicate Mattermost base_url parsing/validation occurs
in the "mattermost" branch (checking credentials.mattermost_base_url with
url::Url::parse and the path/query/fragment conditions) and should be extracted
into a small helper to avoid drift; add a function like
validate_mattermost_base_url(url: &str) -> Result<(), String> (or similar) that
encapsulates the parse and the condition (path == "/" && query.is_none() &&
fragment.is_none()), call that helper from both places, and on Err return the
same Json(MessagingInstanceActionResponse { success: false, message: ... })
behavior; update places that currently set platform_table["base_url"] to use the
validated url after the helper returns Ok.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 9929b306-caea-415a-ad85-11cf9986a955
📒 Files selected for processing (4)
src/api/messaging.rssrc/config/load.rssrc/messaging/mattermost.rssrc/messaging/target.rs
channel_ids filter at line 1543 only checked Discord/Slack/Twitch metadata, causing it to return false for Mattermost messages before the Mattermost- specific block ran. Added mattermost_channel_id to the direct_match set. require_mention routing had no mattermost arm in the mention_key switch, so all Mattermost messages were dropped when require_mention = true. Added mattermost -> mattermost_mentions_or_replies_to_bot mapping.
target.rs: Default DM conv IDs (mattermost:team:dm:user, 4 parts) were
matching the named-instance arm in extract_mattermost_adapter_from_channel_id
and returning mattermost:{team_id} as the adapter. Fixed match arm ordering
so the default-DM pattern is checked before the 4-part named-channel pattern.
target.rs: normalize_mattermost_target accepted bare 'dm' as a valid channel
ID. Added != 'dm' guard to the single-segment arm.
mattermost.rs: StreamEnd overflow chunks were posted with root_id = None,
escaping the original thread. Now resolves root_id from mattermost_root_id /
REPLY_TO_MESSAGE_ID metadata and passes it to all overflow create_post calls.
mattermost.rs: WS post JSON decode failures were silently dropped. Now logs
at DEBUG level via tracing::debug so malformed payloads are visible.
Use Cow<str> so the common case (text under MAX_MESSAGE_LENGTH) borrows accumulated_text directly instead of cloning it on every throttled edit.
There was a problem hiding this comment.
Actionable comments posted: 4
♻️ Duplicate comments (2)
src/messaging/mattermost.rs (2)
432-433:⚠️ Potential issue | 🟡 MinorDo not discard lifecycle Results silently.
OnceCell::set(...).ok()(Lines 432-433) andlet _ = tx.send(()).await(Line 916) drop error context and make startup/shutdown state transitions harder to diagnose.As per coding guidelines "Don't silently discard errors. No `let _ =` on Results. Handle them, log them, or propagate them. The only exception is `.ok()` on channel sends where the receiver may be dropped".🛠️ Proposed explicit handling
- self.bot_user_id.set(user_id.clone()).ok(); - self.bot_username.set(username.clone()).ok(); + if self.bot_user_id.set(user_id.clone()).is_err() { + tracing::debug!(adapter = %self.runtime_key, "bot_user_id already initialized"); + } + if self.bot_username.set(username.clone()).is_err() { + tracing::debug!(adapter = %self.runtime_key, "bot_username already initialized"); + } @@ - if let Some(tx) = self.shutdown_tx.write().await.take() { - let _ = tx.send(()).await; - } + if let Some(tx) = self.shutdown_tx.write().await.take() + && let Err(error) = tx.send(()).await + { + tracing::debug!(%error, adapter = %self.runtime_key, "shutdown signal receiver dropped"); + }Also applies to: 915-917
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/messaging/mattermost.rs` around lines 432 - 433, The two OnceCell set calls (self.bot_user_id.set(...) and self.bot_username.set(...)) and the tx.send(()).await are currently discarding Results via .ok() and let _ = ..., so replace those silent drops with explicit handling: check the Result from self.bot_user_id.set(...) and self.bot_username.set(...) (or use try_set/expect if startup must fail) and log or return an Err with contextual messages if setting fails, and handle tx.send(()).await by matching the Result and logging the send error or propagating it instead of ignoring it; update places referencing bot_user_id, bot_username, and the tx.send call to use match/if let Err(e) { /* log e or return Err(e) */ } so lifecycle failures are surfaced.
991-1002:⚠️ Potential issue | 🟠 MajorCheck
/postsstatus in the broadcast file path.At Line 991, non-2xx responses from creating the post after upload are currently treated as success, so file broadcasts can fail silently.
🧪 Proposed status/body handling
- self.client + let post_response = self.client .post(self.api_url("/posts")) .bearer_auth(self.token.as_ref()) .json(&serde_json::json!({ "channel_id": target, "message": caption.unwrap_or_default(), "file_ids": file_ids, })) .send() .await .context("failed to create post with file")?; + let post_status = post_response.status(); + if !post_status.is_success() { + let body = post_response.text().await.unwrap_or_default(); + return Err(anyhow::anyhow!( + "mattermost POST /posts (broadcast file) failed with status {}: {body}", + post_status.as_u16() + ).into()); + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/messaging/mattermost.rs` around lines 991 - 1002, The post-create call currently ignores non-2xx responses and treats them as success; change the self.client.post(self.api_url("/posts")).bearer_auth(self.token.as_ref()).json(...)...send().await call to capture the Response into a variable, await and then verify response.status().is_success(); if not successful, read response.text().await and return an error (or context) that includes the HTTP status and response body so failures aren't silent. Replace the existing chained .send().await.context("failed to create post with file")? with this explicit check around the response from the post request.
🧹 Nitpick comments (2)
src/config/types.rs (2)
1565-1576: Consolidate Mattermost channel filtering to one code path.
channel_idsfor Mattermost is checked in both the generic block and a Mattermost-specific block, which duplicates logic and risks drift.♻️ Proposed simplification
- // Mattermost channel filter - if !self.channel_ids.is_empty() && self.channel == "mattermost" { - let message_channel = message - .metadata - .get("mattermost_channel_id") - .and_then(|v| v.as_str()); - if !self.channel_ids.iter().any(|id| Some(id.as_str()) == message_channel) { - return false; - } - }Also applies to: 1610-1619
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/config/types.rs` around lines 1565 - 1576, The Mattermost channel ID check is duplicated across the generic channel matching and a Mattermost-specific block; consolidate by keeping a single check path: compute mattermost_channel once from message.metadata (as currently done) and include it only in the unified direct_match expression that checks message_channel, slack_channel, twitch_channel and mattermost_channel against self.channel_ids (using consistent to_string() conversion where needed), then remove the redundant Mattermost-specific containment check in the other block (the similar logic around the 1610-1619 region) so all channel filtering uses the same variables and comparison logic.
1889-1891: Use descriptive closure variable names.Please rename
itoinstancefor readability and guideline alignment.✏️ Small cleanup
- mattermost.instances.iter().map(|i| i.name.as_str()), + mattermost + .instances + .iter() + .map(|instance| instance.name.as_str()),As per coding guidelines: "Don't abbreviate variable names. Use
queuenotq,messagenotmsg,channelnotch."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/config/types.rs` around lines 1889 - 1891, The closure passed to validate_instance_names uses the abbreviated variable name `i`; rename it to a descriptive name like `instance` (e.g., change `mattermost.instances.iter().map(|i| i.name.as_str())` to use `|instance| instance.name.as_str()`) to improve readability and follow naming guidelines; ensure any other occurrences in the same expression or nearby bindings are updated to the new name.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/config/types.rs`:
- Around line 1916-1932: The validate_mattermost_url function currently allows
"http" and only warns; change it to fail closed: in validate_mattermost_url,
after parsing the URL examine parsed.scheme() and parsed.host_str(), and if
scheme == "http" then only allow it when the host is an explicit local/dev host
(e.g., host_str == Some("localhost") or host_str starts_with "127." or host_str
== Some("::1") or parsed.host_str().map(|h|
h.ends_with(".local")).unwrap_or(false)); otherwise return
Err(ConfigError::Invalid with a message that non-HTTPS Mattermost base_url is
not allowed except for local hosts). Ensure references to
validate_mattermost_url and parsed are used so the change is easy to locate.
- Around line 2646-2694: Add a SystemSecrets impl for MattermostConfig and
MattermostInstanceConfig that registers their token fields as secret fields so
they are included in the global secret_fields() collection; specifically
implement the SystemSecrets trait for MattermostConfig (which contains
instances: Vec<MattermostInstanceConfig>) and for MattermostInstanceConfig and
ensure the implementation registers the "token" field (and any per-instance
secret keys if your SystemSecrets API requires a fully-qualified path) so the
unified secret/env injection used elsewhere will pick up Mattermost tokens
instead of falling back to manual env handling.
In `@src/messaging/mattermost.rs`:
- Around line 1052-1060: The current logic in the message acceptance block using
permissions.channel_filter, team_id, and post.channel_id is fail-open when a
team has no entry; change it to fail-closed by treating a missing map entry as
disallowing all channels: if permissions.channel_filter is non-empty, require
Some(tid) for team_id, then if permissions.channel_filter.get(&tid) returns
None, return None (reject); otherwise proceed to check
allowed_channels.contains(&post.channel_id) as before.
- Around line 663-681: The code is awaiting self.edit_post(...) while holding
the active_messages write lock; extract the required data (e.g., clone
display_text and post_id and maybe a copy of message.id) while holding the lock,
drop the write lock before calling self.edit_post(&post_id, &display_text).
After the await, re-acquire the write lock and verify the active entry still
exists and corresponds to the same post_id, then update active.last_edit (and
accumulated_text if needed). Use the same symbols from the diff:
active_messages, active.accumulated_text, active.post_id, active.last_edit,
STREAM_EDIT_THROTTLE, MAX_MESSAGE_LENGTH, and method self.edit_post to implement
this lock-safe refactor.
---
Duplicate comments:
In `@src/messaging/mattermost.rs`:
- Around line 432-433: The two OnceCell set calls (self.bot_user_id.set(...) and
self.bot_username.set(...)) and the tx.send(()).await are currently discarding
Results via .ok() and let _ = ..., so replace those silent drops with explicit
handling: check the Result from self.bot_user_id.set(...) and
self.bot_username.set(...) (or use try_set/expect if startup must fail) and log
or return an Err with contextual messages if setting fails, and handle
tx.send(()).await by matching the Result and logging the send error or
propagating it instead of ignoring it; update places referencing bot_user_id,
bot_username, and the tx.send call to use match/if let Err(e) { /* log e or
return Err(e) */ } so lifecycle failures are surfaced.
- Around line 991-1002: The post-create call currently ignores non-2xx responses
and treats them as success; change the
self.client.post(self.api_url("/posts")).bearer_auth(self.token.as_ref()).json(...)...send().await
call to capture the Response into a variable, await and then verify
response.status().is_success(); if not successful, read response.text().await
and return an error (or context) that includes the HTTP status and response body
so failures aren't silent. Replace the existing chained
.send().await.context("failed to create post with file")? with this explicit
check around the response from the post request.
---
Nitpick comments:
In `@src/config/types.rs`:
- Around line 1565-1576: The Mattermost channel ID check is duplicated across
the generic channel matching and a Mattermost-specific block; consolidate by
keeping a single check path: compute mattermost_channel once from
message.metadata (as currently done) and include it only in the unified
direct_match expression that checks message_channel, slack_channel,
twitch_channel and mattermost_channel against self.channel_ids (using consistent
to_string() conversion where needed), then remove the redundant
Mattermost-specific containment check in the other block (the similar logic
around the 1610-1619 region) so all channel filtering uses the same variables
and comparison logic.
- Around line 1889-1891: The closure passed to validate_instance_names uses the
abbreviated variable name `i`; rename it to a descriptive name like `instance`
(e.g., change `mattermost.instances.iter().map(|i| i.name.as_str())` to use
`|instance| instance.name.as_str()`) to improve readability and follow naming
guidelines; ensure any other occurrences in the same expression or nearby
bindings are updated to the new name.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 168aa834-375d-49d8-9b1f-0d4dbe25e9c0
📒 Files selected for processing (3)
src/config/types.rssrc/messaging/mattermost.rssrc/messaging/target.rs
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (1)
src/messaging/mattermost.rs (1)
995-1005:⚠️ Potential issue | 🟠 MajorMissing HTTP status check for post creation after file upload.
The
/postsresponse status is not checked, so 4xx/5xx errors are silently ignored. This is inconsistent with the same logic inrespond()(lines 819-826) which properly checkspost_status.is_success().🐛 Proposed fix
- self.client + let post_response = self + .client .post(self.api_url("/posts")) .bearer_auth(self.token.as_ref()) .json(&serde_json::json!({ "channel_id": target, "message": caption.unwrap_or_default(), "file_ids": file_ids, })) .send() .await .context("failed to create post with file")?; + let post_status = post_response.status(); + if !post_status.is_success() { + let body = post_response.text().await.unwrap_or_default(); + return Err(anyhow::anyhow!( + "mattermost POST /posts (file broadcast) failed with status {}: {body}", + post_status.as_u16() + ).into()); + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/messaging/mattermost.rs` around lines 995 - 1005, The post-creation call using self.client.post(self.api_url("/posts")) does not check the HTTP response status so 4xx/5xx failures are ignored; change the call in the block that posts with file_ids to capture the response (await the send into a variable), check response.status().is_success(), and return an error/context if not successful (similar to the existing logic in respond()), including the status and any body text in the error message to aid debugging; locate the code around the POST to "/posts" where .bearer_auth, .json and .send().await are used and apply the same status-check pattern as in respond().
🧹 Nitpick comments (2)
src/messaging/mattermost.rs (2)
490-490: Consider logging malformed WebSocket event envelopes.JSON parse failures for the outer event structure are silently dropped, which can make debugging integration issues harder. The post payload parsing (lines 499-506) already logs errors correctly.
🔧 Suggested fix
- if let Ok(event) = serde_json::from_str::<MattermostWsEvent>(&text) { + let event = match serde_json::from_str::<MattermostWsEvent>(&text) { + Ok(e) => e, + Err(error) => { + tracing::trace!(%error, "failed to parse Mattermost WS event envelope"); + continue; + } + };🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/messaging/mattermost.rs` at line 490, The WebSocket message parsing silently ignores failures when deserializing into MattermostWsEvent; update the code around the serde_json::from_str::<MattermostWsEvent>(&text) branch to log parse errors (including the raw text and the serde error) rather than dropping them silently so malformed outer envelopes are visible; locate the parsing site that uses MattermostWsEvent and add a processLogger/error (or tracing::error) call that records the error and the offending text before continuing.
1484-1510: Consider adding a test for channel_filter with unconfigured team.The existing tests verify fail-closed when
team_idis absent, but there's no test case for whenchannel_filteris configured and a message comes from a team that has no entry in the map. This would help catch the fail-open behavior flagged above.🧪 Suggested test case
#[test] fn channel_filter_rejects_team_without_entry() { let p = post("user1", "chan1", None); let mut cf = HashMap::new(); cf.insert("team1".into(), vec!["chan1".into()]); let perms = MattermostPermissions { team_filter: None, channel_filter: cf, dm_allowed_users: vec![] }; // team2 has no entry in channel_filter → should reject (fail-closed) assert!(bmfp(&p, "bot", Some("team2"), &perms).is_none()); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/messaging/mattermost.rs` around lines 1484 - 1510, Add a unit test that verifies bmfp rejects messages from a team not present in MattermostPermissions.channel_filter: create a post via post("user1","chan1",None), build channel_filter HashMap containing only "team1" (with "chan1"), construct MattermostPermissions with that channel_filter, then call bmfp(&p, "bot", Some("team2"), &perms) and assert it returns None to ensure fail-closed behavior when the team key is missing.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/messaging/mattermost.rs`:
- Line 483: The WebSocket close send call currently swallows errors with "let _
= write.send(WsMessage::Close(None)).await;"; change this to capture the Result
from write.send(WsMessage::Close(None)).await and log any Err using the existing
logger (or connection-specific logger) rather than discarding it; locate the
call to write.send(...) and replace the ignored await with error handling that
records the failure (including the error) so Close send failures are visible in
logs.
---
Duplicate comments:
In `@src/messaging/mattermost.rs`:
- Around line 995-1005: The post-creation call using
self.client.post(self.api_url("/posts")) does not check the HTTP response status
so 4xx/5xx failures are ignored; change the call in the block that posts with
file_ids to capture the response (await the send into a variable), check
response.status().is_success(), and return an error/context if not successful
(similar to the existing logic in respond()), including the status and any body
text in the error message to aid debugging; locate the code around the POST to
"/posts" where .bearer_auth, .json and .send().await are used and apply the same
status-check pattern as in respond().
---
Nitpick comments:
In `@src/messaging/mattermost.rs`:
- Line 490: The WebSocket message parsing silently ignores failures when
deserializing into MattermostWsEvent; update the code around the
serde_json::from_str::<MattermostWsEvent>(&text) branch to log parse errors
(including the raw text and the serde error) rather than dropping them silently
so malformed outer envelopes are visible; locate the parsing site that uses
MattermostWsEvent and add a processLogger/error (or tracing::error) call that
records the error and the offending text before continuing.
- Around line 1484-1510: Add a unit test that verifies bmfp rejects messages
from a team not present in MattermostPermissions.channel_filter: create a post
via post("user1","chan1",None), build channel_filter HashMap containing only
"team1" (with "chan1"), construct MattermostPermissions with that
channel_filter, then call bmfp(&p, "bot", Some("team2"), &perms) and assert it
returns None to ensure fail-closed behavior when the team key is missing.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: af866ad2-baa4-4bc9-bc37-37292aa5557f
📒 Files selected for processing (1)
src/messaging/mattermost.rs
- validate_mattermost_url: fail-closed for non-localhost http (was warn-only) - MattermostConfig: add SystemSecrets impl and register in system_secret_registry - StreamChunk: release active_messages write lock before awaiting edit_post - channel_filter: fail-closed when team has no allowlist entry (was fail-open) - WS shutdown: log close frame send failure instead of discarding with let _ = Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- channel_filter: add test for fail-closed behavior when team is missing from filter - WS event loop: log outer MattermostWsEvent parse failures (was silently dropped) - broadcast: check HTTP status on /posts with file_ids (was unchecked) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
@coderabbitai resume |
✅ Actions performedReviews resumed. |
There was a problem hiding this comment.
♻️ Duplicate comments (2)
src/messaging/mattermost.rs (1)
432-433:⚠️ Potential issue | 🟡 MinorAvoid silently discarding startup/shutdown Results.
OnceCell::set(...).ok()(Line 432-433) andlet _ = tx.send(()).await(Line 932) hide failure paths. Handle/log the former explicitly, and use the channel-send exception pattern (.ok()) for the latter.🔧 Proposed fix
- self.bot_user_id.set(user_id.clone()).ok(); - self.bot_username.set(username.clone()).ok(); + if self.bot_user_id.set(user_id.clone()).is_err() { + tracing::debug!(adapter = %self.runtime_key, "bot_user_id already initialized"); + } + if self.bot_username.set(username.clone()).is_err() { + tracing::debug!(adapter = %self.runtime_key, "bot_username already initialized"); + } @@ if let Some(tx) = self.shutdown_tx.write().await.take() { - let _ = tx.send(()).await; + tx.send(()).await.ok(); }#!/bin/bash rg -n 'bot_user_id\.set\(.*\)\.ok\(\)|bot_username\.set\(.*\)\.ok\(\)|let _ = tx\.send\(\(\)\)\.await' src/messaging/mattermost.rsExpected result after the fix: no matches.
As per coding guidelines: "Don't silently discard errors. No
let _ =on Results. Handle them, log them, or propagate them. The only exception is.ok()on channel sends where the receiver may be dropped".Also applies to: 931-933
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/messaging/mattermost.rs` around lines 432 - 433, Replace the silent discards: do not call .ok() on OnceCell::set for self.bot_user_id and self.bot_username; instead capture the Result returned by self.bot_user_id.set(user_id.clone()) and self.bot_username.set(username.clone()) and handle the Err case (e.g., log an error or warning with the existing logger/tracing macro and include the attempted value or reason). For the channel send use the accepted channel-send pattern: call tx.send(()).await and explicitly ignore the Ok or call .ok() on the awaited Result only (e.g., let _ = tx.send(()).await.ok();) so the receiver-dropped case is intentionally silenced while other set failures are logged/handled. Ensure you reference the exact symbols self.bot_user_id.set, self.bot_username.set and tx.send(()).await when making the changes.src/config/types.rs (1)
2706-2717:⚠️ Potential issue | 🟠 MajorNamed Mattermost instance secrets are not registered as system secrets.
MattermostConfigsupportsinstances, butMATTERMOST_TOKENis registered without aninstance_pattern. That breaks parity with other adapters and leavesMATTERMOST_{INSTANCE}_TOKENunmatched by registry-based categorization.🔧 Proposed fix
impl SystemSecrets for MattermostConfig { @@ fn secret_fields() -> &'static [SecretField] { &[ SecretField { toml_key: "token", secret_name: "MATTERMOST_TOKEN", - instance_pattern: None, + instance_pattern: Some(InstancePattern { + platform_prefix: "MATTERMOST", + field_suffix: "TOKEN", + }), }, SecretField { toml_key: "base_url", secret_name: "MATTERMOST_BASE_URL", - instance_pattern: None, + instance_pattern: Some(InstancePattern { + platform_prefix: "MATTERMOST", + field_suffix: "BASE_URL", + }), }, ] } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/config/types.rs` around lines 2706 - 2717, The Mattermost secret_fields list registers MATTERMOST_TOKEN and MATTERMOST_BASE_URL without instance_pattern, so per-instance secrets like MATTERMOST_{INSTANCE}_TOKEN are not recognized; update the SecretField entries inside the secret_fields() function for MattermostConfig (the SecretField with toml_key "token" and the one with "base_url") to include appropriate instance_pattern values (e.g. pattern matching "{instance}_TOKEN" and "{instance}_BASE_URL" or the project’s existing instance placeholder format) so named Mattermost instance secrets are registered like the other adapters.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Duplicate comments:
In `@src/config/types.rs`:
- Around line 2706-2717: The Mattermost secret_fields list registers
MATTERMOST_TOKEN and MATTERMOST_BASE_URL without instance_pattern, so
per-instance secrets like MATTERMOST_{INSTANCE}_TOKEN are not recognized; update
the SecretField entries inside the secret_fields() function for MattermostConfig
(the SecretField with toml_key "token" and the one with "base_url") to include
appropriate instance_pattern values (e.g. pattern matching "{instance}_TOKEN"
and "{instance}_BASE_URL" or the project’s existing instance placeholder format)
so named Mattermost instance secrets are registered like the other adapters.
In `@src/messaging/mattermost.rs`:
- Around line 432-433: Replace the silent discards: do not call .ok() on
OnceCell::set for self.bot_user_id and self.bot_username; instead capture the
Result returned by self.bot_user_id.set(user_id.clone()) and
self.bot_username.set(username.clone()) and handle the Err case (e.g., log an
error or warning with the existing logger/tracing macro and include the
attempted value or reason). For the channel send use the accepted channel-send
pattern: call tx.send(()).await and explicitly ignore the Ok or call .ok() on
the awaited Result only (e.g., let _ = tx.send(()).await.ok();) so the
receiver-dropped case is intentionally silenced while other set failures are
logged/handled. Ensure you reference the exact symbols self.bot_user_id.set,
self.bot_username.set and tx.send(()).await when making the changes.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: c0c6d1a4-f4d4-4455-a752-c2b661503d3c
📒 Files selected for processing (3)
src/config/types.rssrc/messaging/mattermost.rssrc/secrets/store.rs
- SystemSecrets: add InstancePattern to token and base_url fields so
MATTERMOST_{INSTANCE}_TOKEN/BASE_URL are recognized as system secrets
- start(): log warning when bot_user_id/bot_username OnceCell is already set
instead of silently discarding the error
- shutdown(): use tx.send().await.ok() pattern for intentional channel-send discard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
src/config/types.rs (1)
1543-1583: Deduplicate Mattermostchannel_idsfiltering inmatches_route.At Line 1576, Mattermost channel matching is already part of
direct_match; the second Mattermost-only check at Line 1611 repeats the same constraint. Keeping one check reduces drift risk.♻️ Simplify duplicated logic
- // Mattermost channel filter - if !self.channel_ids.is_empty() && self.channel == "mattermost" { - let message_channel = message - .metadata - .get("mattermost_channel_id") - .and_then(|v| v.as_str()); - if !self.channel_ids.iter().any(|id| Some(id.as_str()) == message_channel) { - return false; - } - }Also applies to: 1611-1619
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/config/types.rs` around lines 1543 - 1583, The matches_route logic duplicates the Mattermost channel check: remove the second Mattermost-only filter block that separately checks mattermost_channel against self.channel_ids since mattermost_channel is already included in direct_match (the variables mattermost_channel, direct_match, parent_match, and self.channel_ids are in this function); ensure you keep the parent_channel parent_match logic unchanged and rely on direct_match to cover Slack/Twitch/Mattermost direct matches to avoid redundant checks.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/config/types.rs`:
- Around line 1916-1947: The validate_mattermost_url function currently only
checks scheme/host; update it to reject URLs that include embedded credentials
or extra components by asserting parsed.username().is_empty(),
parsed.password().is_none(), parsed.query().is_none(),
parsed.fragment().is_none(), and that parsed.path() is either empty or "/"
(reject any other path); if any of these checks fail return a
ConfigError::Invalid with a clear message. Add these checks after parsing and
before the scheme match, referencing validate_mattermost_url and the parsed.url
methods (parsed.username(), parsed.password(), parsed.path(), parsed.query(),
parsed.fragment()) so the config only accepts origin-only https/http URLs
without credentials or extra components.
In `@src/messaging/mattermost.rs`:
- Around line 79-80: The code currently reads event.broadcast.team_id directly
and ignores the documented fallback; change the WS event handling to use the
constructor-provided default_team_id when event.broadcast.team_id is None.
Specifically, where you currently access event.broadcast.team_id (used in the
event processing/dispatch path), replace that direct access with something like:
let team_id = event.broadcast.team_id.clone().or(self.default_team_id.clone());
and then use team_id for routing/permission checks so the default_team_id is
applied whenever the event omits a team_id.
---
Nitpick comments:
In `@src/config/types.rs`:
- Around line 1543-1583: The matches_route logic duplicates the Mattermost
channel check: remove the second Mattermost-only filter block that separately
checks mattermost_channel against self.channel_ids since mattermost_channel is
already included in direct_match (the variables mattermost_channel,
direct_match, parent_match, and self.channel_ids are in this function); ensure
you keep the parent_channel parent_match logic unchanged and rely on
direct_match to cover Slack/Twitch/Mattermost direct matches to avoid redundant
checks.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 8a67aac1-db48-41ff-bc03-59c417b8fd15
📒 Files selected for processing (2)
src/config/types.rssrc/messaging/mattermost.rs
- validate_mattermost_url: reject URLs with credentials, non-root path, query string, or fragment (accept origin-only URLs only) - start()/WS task: apply default_team_id fallback when WS event omits team_id - matches_route: remove duplicate Mattermost-only channel_ids filter block (mattermost_channel_id is already covered by direct_match) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (2)
src/config/types.rs (1)
1880-1880: Use a descriptive closure variable name instead ofi.At Line 1880,
map(|i| i.name.as_str())should use a descriptive name for readability and guideline compliance.✏️ Suggested tweak
- mattermost.instances.iter().map(|i| i.name.as_str()), + mattermost.instances.iter().map(|instance| instance.name.as_str()),As per coding guidelines: "Don't abbreviate variable names. Use queue not q, message not msg, channel not ch."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/config/types.rs` at line 1880, The closure in the iterator mapping uses a non-descriptive variable name `i`; change the closure in mattermost.instances.iter().map(|i| i.name.as_str()) to use a clear name like `instance` (e.g., map(|instance| instance.name.as_str())) to improve readability and follow naming guidelines while keeping behavior identical.src/messaging/mattermost.rs (1)
1455-1461: Rename abbreviated test helpers for clarity and guideline compliance.
bmfp,bmfp_named, and single-letter params (p) are harder to scan in this already dense test module.As per coding guidelines: "Don't abbreviate variable names. Use queue not q, message not msg, channel not ch. Common abbreviations like config are fine".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/messaging/mattermost.rs` around lines 1455 - 1461, Rename the abbreviated test helpers and single-letter parameters for clarity: change functions bmfp and bmfp_named to descriptive names (e.g., build_message_from_mattermost_post and build_message_from_mattermost_post_named) and rename the parameter p to post (and any other single-letter params) in their signatures and all local call sites; update the calls to build_message_from_post inside these helpers to use the new parameter names and ensure bot_id, bot_username, team_id, perms, display_name, and channel_name arguments remain unchanged so behavior is preserved.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/config/types.rs`:
- Line 1476: Binding.team_id was added to the config model but the API layer's
BindingResponse type (and its conversions) still omits it; update the bindings
API so clients can read/write team scoping by adding team_id to the
BindingResponse struct (and any request/patch DTOs if present), include it in
the serde/Serialize/Deserialize derives, and ensure the model↔response mapping
code (the conversion/from implementations used to build BindingResponse) copies
Binding.team_id into the response and vice versa for incoming updates.
- Around line 2759-2794: Add unit tests to mattermost_url_tests that exercise
the new rejection branches in validate_mattermost_url: create tests that assert
Err for URLs containing userinfo/credentials (e.g.,
"https://user:pass@mattermost.example.com"), for URLs with non-empty paths
beyond the root (e.g., "https://mattermost.example.com/some/path"), for URLs
containing query strings (e.g., "https://mattermost.example.com/?token=abc") and
for URLs with fragments (e.g., "https://mattermost.example.com/#section"); keep
existing ok assertions for plain https and localhost http and use
assert!(validate_mattermost_url(...).is_err()) or .is_ok() as appropriate to
verify each rejection rule in validate_mattermost_url.
In `@src/messaging/mattermost.rs`:
- Around line 826-833: The JSON payload sent in the post request to
self.client.post(self.api_url("/posts")) is missing root_id, so file replies can
escape the original thread; include the thread root id (e.g., root_id or
thread_root_id used elsewhere in this flow) in the payload alongside channel_id,
message (caption) and file_ids by adding "root_id": root_id (or the appropriate
variable holding the original post's root id) to the serde_json::json! call to
ensure file responses stay in the same thread as text/stream replies.
- Around line 650-664: In the OutboundResponse::StreamStart branch, ensure the
typing loop started by start_typing(channel_id).await is stopped if
create_post(channel_id, "\u{200B}", root_id).await returns an error; change the
code to call self.stop_typing(channel_id).await (or the equivalent typing-stop
helper) before returning the Err so the typing task is not leaked — e.g.,
perform create_post in a match or map_err that first awaits
self.stop_typing(channel_id) and then propagates the original error; update
references to start_typing, create_post, and the error return path around the
active_messages insertion accordingly.
---
Nitpick comments:
In `@src/config/types.rs`:
- Line 1880: The closure in the iterator mapping uses a non-descriptive variable
name `i`; change the closure in mattermost.instances.iter().map(|i|
i.name.as_str()) to use a clear name like `instance` (e.g., map(|instance|
instance.name.as_str())) to improve readability and follow naming guidelines
while keeping behavior identical.
In `@src/messaging/mattermost.rs`:
- Around line 1455-1461: Rename the abbreviated test helpers and single-letter
parameters for clarity: change functions bmfp and bmfp_named to descriptive
names (e.g., build_message_from_mattermost_post and
build_message_from_mattermost_post_named) and rename the parameter p to post
(and any other single-letter params) in their signatures and all local call
sites; update the calls to build_message_from_post inside these helpers to use
the new parameter names and ensure bot_id, bot_username, team_id, perms,
display_name, and channel_name arguments remain unchanged so behavior is
preserved.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 1f61c590-be6c-4550-b641-5074bd25a9c6
📒 Files selected for processing (2)
src/config/types.rssrc/messaging/mattermost.rs
- bindings API: add team_id to BindingResponse, CreateBindingRequest, and UpdateBindingRequest so Mattermost team scoping is readable/writable via API - validate_mattermost_url: reject URLs with credentials, non-root path, query string, or fragment; add tests covering all new rejection branches - broadcast File: include root_id in /posts payload so file replies stay in thread - StreamStart: stop typing task before propagating create_post error to prevent leak - config/types.rs: rename |i| closure var to |instance| for readability - mattermost tests: rename bmfp/bmfp_named helpers and p param to descriptive names Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace implementation plan with concise feature list, setup guide, named instance configuration, and notes on what is missing compared to Slack/Discord. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/api/bindings.rs (1)
141-156:⚠️ Potential issue | 🟠 MajorCarry
team_idthrough the delete flow too.
team_idis now part of the binding identity for create/list/update, butDeleteBindingRequestanddelete_binding()still ignore it. Two Mattermost bindings that only differ by team become indistinguishable here, so the delete API can remove the wrong row.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/api/bindings.rs` around lines 141 - 156, DeleteBindingRequest and delete_binding() currently ignore team_id, so deletes can remove the wrong binding when multiple bindings differ only by team_id; add team_id: Option<String> (with #[serde(default)]) to the DeleteBindingRequest struct, propagate that field into delete_binding() and update the DB delete logic to include team_id in the WHERE clause (e.g., add "AND team_id = $N" when team_id.is_some(), or bind NULL/IS NULL semantics as used elsewhere), and update any callsites/tests to supply/expect team_id-aware deletion. Ensure parameter names match existing symbols (DeleteBindingRequest, delete_binding) so the binding identity includes team_id for delete operations.
♻️ Duplicate comments (1)
src/messaging/mattermost.rs (1)
1177-1183:⚠️ Potential issue | 🟠 MajorSet the directed-message flag for replies and DMs, not just
@mentions.
passes_require_mention()usesmattermost_mentions_or_replies_to_botas the adapter's allow signal. This only checks for an@{bot_username}substring, so thread replies to the bot—and DMs when a binding hasrequire_mention = true—still get dropped.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/messaging/mattermost.rs` around lines 1177 - 1183, passes_require_mention() relies on the metadata key "mattermost_mentions_or_replies_to_bot", but the current mentions_bot calculation only checks post.message.contains(format!("@{bot_username}")); update the logic around mentions_bot (the let mentions_bot = ... and subsequent metadata.insert) to also set true when the message is a direct message (check post.channel_type or equivalent DM indicator) and when it is a thread reply to a post authored by the bot (check post.root_id, fetch the parent post and compare parent.user_id to bot_user_id); keep the original `@mention` test as well and then insert the combined boolean into the same metadata key so replies and DMs are treated as directed messages.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/api/bindings.rs`:
- Around line 444-446: The create path currently persists Some("") for team_id
while update_binding() treats Some("") as absent; change the create logic in
bindings.rs so that when handling request.team_id you only set
binding_table["team_id"] if the string is non-empty (e.g., check
!team_id.trim().is_empty()), otherwise treat it as absent and do not write the
key—this normalizes empty team_id to the same behavior as update_binding().
In `@src/messaging/mattermost.rs`:
- Around line 1431-1438: The split logic can push an empty string when
search_region.rfind returns 0; update the loop that computes break_point (using
search_region, search_end, remaining, chunks) to guard against break_point == 0
by trimming leading whitespace/newlines from remaining and recomputing the
search region (or falling back to using search_end) before slicing and pushing;
ensure you do not push an empty chunk (i.e., only push
remaining[..end].to_string() when end > 0) and preserve the existing use of
floor_char_boundary when computing end.
---
Outside diff comments:
In `@src/api/bindings.rs`:
- Around line 141-156: DeleteBindingRequest and delete_binding() currently
ignore team_id, so deletes can remove the wrong binding when multiple bindings
differ only by team_id; add team_id: Option<String> (with #[serde(default)]) to
the DeleteBindingRequest struct, propagate that field into delete_binding() and
update the DB delete logic to include team_id in the WHERE clause (e.g., add
"AND team_id = $N" when team_id.is_some(), or bind NULL/IS NULL semantics as
used elsewhere), and update any callsites/tests to supply/expect team_id-aware
deletion. Ensure parameter names match existing symbols (DeleteBindingRequest,
delete_binding) so the binding identity includes team_id for delete operations.
---
Duplicate comments:
In `@src/messaging/mattermost.rs`:
- Around line 1177-1183: passes_require_mention() relies on the metadata key
"mattermost_mentions_or_replies_to_bot", but the current mentions_bot
calculation only checks post.message.contains(format!("@{bot_username}"));
update the logic around mentions_bot (the let mentions_bot = ... and subsequent
metadata.insert) to also set true when the message is a direct message (check
post.channel_type or equivalent DM indicator) and when it is a thread reply to a
post authored by the bot (check post.root_id, fetch the parent post and compare
parent.user_id to bot_user_id); keep the original `@mention` test as well and then
insert the combined boolean into the same metadata key so replies and DMs are
treated as directed messages.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: b14df601-66dd-4577-b7be-0fb451c84b5d
📒 Files selected for processing (3)
src/api/bindings.rssrc/config/types.rssrc/messaging/mattermost.rs
- create_binding: guard empty team_id to match update_binding behavior - DeleteBindingRequest: add team_id to prevent wrong-binding deletion - split_message: filter break_point == 0 to prevent empty chunk push - mentions_bot: set true for DMs; detect thread replies to bot via async root-post fetch in WS handler (resolve_root_post_author) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
src/messaging/mattermost.rs (1)
132-144: Consider rejecting unsupported schemes instead of passing through.The
other => otherfallback at line 137 silently passes through unknown schemes, which would produce a broken WebSocket URL. Given that the constructor validates the URL structure, this is unlikely to be reached, but failing explicitly would be clearer.💡 Suggested defensive fix
url.set_scheme(match self.base_url.scheme() { "https" => "wss", "http" => "ws", - other => other, + other => { + tracing::warn!(scheme = other, "unsupported base_url scheme for websocket"); + other + } })🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/messaging/mattermost.rs` around lines 132 - 144, The ws_url method currently silently passes through unknown schemes via the match fallback (other => other) which can produce invalid websocket URLs; update ws_url to explicitly reject unsupported schemes by replacing the fallback with an explicit failure that includes the offending scheme (e.g., panic!/expect with a message like "unsupported URL scheme: {scheme}"), so in the match over self.base_url.scheme() only "http" => "ws" and "https" => "wss" are accepted and any other scheme causes a clear, immediate error; make this change inside the ws_url function referencing self.base_url and the scheme substitution to help locate the code.src/api/bindings.rs (1)
737-737: Replace abbreviated variable names with descriptive names (team_idnottid,guild_idnotgid,workspace_idnotwid,chat_idnotcid).This pattern appears across two parallel code blocks (lines 716–737 and 924–945) where abbreviated names are used in match arm bindings. Repo guidelines require descriptive local names: "Don't abbreviate variable names. Use
queuenotq,messagenotmsg,channelnotch."🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/api/bindings.rs` at line 737, In the match arms where you bind Option tuples (e.g. the snippet showing "Some(tid) => table"), replace abbreviated local bindings with descriptive names—use team_id instead of tid, guild_id instead of gid, workspace_id instead of wid, and chat_id instead of cid—so the match arms and any subsequent uses refer to these full names; update both parallel blocks that follow the same pattern (the block containing "Some(tid) => table" and the corresponding later block) to maintain consistency and compile-time correctness by renaming the variables wherever they are referenced in those match arms and their scopes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/api/bindings.rs`:
- Around line 736-742: Normalize team_id the same way everywhere: when reading
request.original_team_id and when reading/writing table.get("team_id") treat
whitespace-only strings as None and trim surrounding whitespace before
comparisons and when storing; update the code paths that set/compare team_id
(the match using request.original_team_id, the storage code that currently
allows whitespace-only team_id, and the comparison logic around
table.get("team_id")) so they all trim().is_empty() => treat as None, otherwise
use trimmed value for equality checks and persisted values.
---
Nitpick comments:
In `@src/api/bindings.rs`:
- Line 737: In the match arms where you bind Option tuples (e.g. the snippet
showing "Some(tid) => table"), replace abbreviated local bindings with
descriptive names—use team_id instead of tid, guild_id instead of gid,
workspace_id instead of wid, and chat_id instead of cid—so the match arms and
any subsequent uses refer to these full names; update both parallel blocks that
follow the same pattern (the block containing "Some(tid) => table" and the
corresponding later block) to maintain consistency and compile-time correctness
by renaming the variables wherever they are referenced in those match arms and
their scopes.
In `@src/messaging/mattermost.rs`:
- Around line 132-144: The ws_url method currently silently passes through
unknown schemes via the match fallback (other => other) which can produce
invalid websocket URLs; update ws_url to explicitly reject unsupported schemes
by replacing the fallback with an explicit failure that includes the offending
scheme (e.g., panic!/expect with a message like "unsupported URL scheme:
{scheme}"), so in the match over self.base_url.scheme() only "http" => "ws" and
"https" => "wss" are accepted and any other scheme causes a clear, immediate
error; make this change inside the ws_url function referencing self.base_url and
the scheme substitution to help locate the code.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 99096b8a-eb9f-4b7c-ae79-51217dfae5fa
📒 Files selected for processing (3)
docs/mattermost.mdsrc/api/bindings.rssrc/messaging/mattermost.rs
✅ Files skipped from review due to trivial changes (1)
- docs/mattermost.md
- bindings: normalize team_id in update/delete matching — trim whitespace and treat whitespace-only values as absent for consistent comparisons - bindings: trim team_id before persisting in update_binding write path - bindings: rename abbreviated match locals (gid/wid/cid/tid) to full names - ws_url: replace silent scheme passthrough with unreachable! for unsupported schemes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
🧹 Nitpick comments (3)
src/api/bindings.rs (1)
736-738: Rename abbreviated locals and hoist request normalization outside the loop.
req_team_idis abbreviated and recalculated on each iteration. Prefer explicit naming and compute request normalization once before theforloop.♻️ Suggested refactor
- for (i, table) in bindings_array.iter().enumerate() { + let normalized_request_team_id = request + .original_team_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + for (i, table) in bindings_array.iter().enumerate() { @@ - let req_team_id = request.original_team_id.as_deref().map(str::trim).filter(|s| !s.is_empty()); - let toml_team_id = table.get("team_id").and_then(|v| v.as_str()).map(str::trim).filter(|s| !s.is_empty()); - let matches_team = req_team_id == toml_team_id; + let normalized_binding_team_id = table + .get("team_id") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()); + let matches_team = normalized_request_team_id == normalized_binding_team_id;As per coding guidelines: "Don't abbreviate variable names. Use
queuenotq,messagenotmsg,channelnotch."Also applies to: 938-940
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/api/bindings.rs` around lines 736 - 738, The local name req_team_id is abbreviated and is being recomputed inside the loop; rename it to request_team_id and compute the normalized request team once before entering the for loop by trimming and filtering request.original_team_id.as_deref(), then inside the loop compute toml_team_id from table.get("team_id") and compare to request_team_id (use explicit names matches_team or team_matches). Apply the same renaming/hoisting pattern to the analogous locals at the other site referenced (lines 938-940) so request normalization is not repeated per-iteration.src/messaging/mattermost.rs (2)
91-95: Minor: unnecessary.into()conversion.Since
new()returnsanyhow::Result<Self>, andanyhow::anyhow!()already produces ananyhow::Error, the.into()call is a no-op here. Compare with methods returningcrate::Resultwhere the conversion is needed.Suggested cleanup
if base_url.path() != "/" || base_url.query().is_some() || base_url.fragment().is_some() { - return Err(anyhow::anyhow!( + anyhow::bail!( "mattermost base_url must be an origin URL without path/query/fragment (got: {})", base_url - ).into()); + ); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/messaging/mattermost.rs` around lines 91 - 95, The error return in Mattermost::new() is performing an unnecessary .into() on an anyhow::Error; remove the .into() and return the anyhow::anyhow!(...) value directly (i.e., use Err(anyhow::anyhow!(...)) ) so the return type matches anyhow::Result<Self> without the redundant conversion.
598-618: Useerrorinstead ofefor error variables.Per coding guidelines: "Don't abbreviate variable names."
Suggested fix
- Some(Err(e)) => { - tracing::error!(adapter = %runtime_key, error = %e, "websocket error"); + Some(Err(error)) => { + tracing::error!(adapter = %runtime_key, %error, "websocket error"); break; } None => break, _ => {} } } } } tracing::info!(adapter = %runtime_key, "websocket disconnected, reconnecting..."); } - Err(e) => { + Err(error) => { tracing::error!( adapter = %runtime_key, - error = %e, + %error, delay_ms = retry_delay.as_millis(), "websocket connection failed, retrying" ); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/messaging/mattermost.rs` around lines 598 - 618, Rename the short error binding `e` to `error` in the websocket connection handling match arms to comply with naming guidelines: update the pattern arms `Some(Err(e))` and `Err(e)` to `Some(Err(error))` and `Err(error)` respectively, and update all uses in the tracing macros (e.g., `tracing::error!(adapter = %runtime_key, error = %e, ... )` and the other tracing call that logs `error = %e`) to reference `%error`; ensure all occurrences within the surrounding block in mattermost.rs (the websocket read loop and retry/error logging) are changed consistently so compilation and log semantics remain the same.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@src/api/bindings.rs`:
- Around line 736-738: The local name req_team_id is abbreviated and is being
recomputed inside the loop; rename it to request_team_id and compute the
normalized request team once before entering the for loop by trimming and
filtering request.original_team_id.as_deref(), then inside the loop compute
toml_team_id from table.get("team_id") and compare to request_team_id (use
explicit names matches_team or team_matches). Apply the same renaming/hoisting
pattern to the analogous locals at the other site referenced (lines 938-940) so
request normalization is not repeated per-iteration.
In `@src/messaging/mattermost.rs`:
- Around line 91-95: The error return in Mattermost::new() is performing an
unnecessary .into() on an anyhow::Error; remove the .into() and return the
anyhow::anyhow!(...) value directly (i.e., use Err(anyhow::anyhow!(...)) ) so
the return type matches anyhow::Result<Self> without the redundant conversion.
- Around line 598-618: Rename the short error binding `e` to `error` in the
websocket connection handling match arms to comply with naming guidelines:
update the pattern arms `Some(Err(e))` and `Err(e)` to `Some(Err(error))` and
`Err(error)` respectively, and update all uses in the tracing macros (e.g.,
`tracing::error!(adapter = %runtime_key, error = %e, ... )` and the other
tracing call that logs `error = %e`) to reference `%error`; ensure all
occurrences within the surrounding block in mattermost.rs (the websocket read
loop and retry/error logging) are changed consistently so compilation and log
semantics remain the same.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 119f75e1-9ab9-4ace-9458-5fde5b7223fa
📒 Files selected for processing (2)
src/api/bindings.rssrc/messaging/mattermost.rs
Summary
src/messaging/mattermost.rs)config.tomlshow up automaticallyWhat's included
Adapter (
src/messaging/mattermost.rs)data.postfield — parsed correctlyStreamEndmattermost:{team_id}:{channel_id}for channels,mattermost:{team_id}:dm:{user_id}for DMsteam_idis absent but filters are configuredmetadata_keys::MESSAGE_IDon inbound messages for reply threadingArc<ArcSwap<MattermostPermissions>>in the file watcherConfig (
src/config/)MattermostConfig/MattermostInstanceConfigwith token-redactingDebugimplsMattermostPermissionswithfrom_config,from_instance_config,from_bindings_for_adapter[messaging.mattermost]TOML section:base_url,token, optionalteam_id,max_attachment_bytes[[messaging.mattermost.instances]]for named instancesteam_idfield onBindingfor channel-level permission scopingSettings UI (
interface/)src/api/messaging.rs: reads[messaging.mattermost]into the instances list; writes credentials to TOML via the create-instance endpointConfig example
Test plan
config.toml— verify instance appears in Settings UIconfig.toml[[messaging.mattermost.instances]]) connect independentlyteam_idin the event is rejected when filters are configured (fail-closed)I based this on the Slack integrations. Written with the help of both Claude and Gemini. Would appreciate review and test feedback.
Note
Introduces full Mattermost messaging adapter with WebSocket event handling, TOML-based configuration, and hot-reloadable permission filtering. Spans 19 files across adapter implementation, config structures, and UI integration (+3400 lines). Follows existing Slack adapter patterns for consistency.
Written by Tembo for commit b069b56.